
"""The inner core model."""

import collections
import json
import logging
import socket
import threading
import time
from gaweb import common
from gaweb import consts
from gaweb.master import helpers
from gaweb.master import excepts


CommandResponse = collections.namedtuple('CommandResponse',
                                         'output image image_data files_changed dims_changed dims ctlinfo display_expr_changed display_expr')


class Master(object):    
    def __init__(self, scheduler=None):
        self._sessions = {}
        self._session_lock = threading.Lock()
        
        if scheduler is None:
            scheduler = common.scheduler

        scheduler(self._do_cleanup, consts.TIMEOUT_CHECK_INTERVAL, repeat=True)

    def _do_cleanup(self):
        """This functions removes sessions that have not pinged
        in the last consts.SESSION_TIMEOUT_INTERVAL milliseconds.
        """
        with self._session_lock:
            sessions = self._sessions.copy()
        
        for session_id, node_con in sessions.iteritems():
            dt = 1000*(time.time() - node_con.last_ping_time)
            if dt > consts.SESSION_TIMEOUT_INTERVAL:
                with self._session_lock:
                    logging.info('Removing session %s' % session_id)
                    node_con.finish()
                    try:
                        del self._sessions[session_id]
                    except KeyError:
                        pass

    def _get_session(self, session_id, check_connected=True):
        """Holds the session dictionary lock and returns a session.    

        Raises a SessionError if the session was not found.
        Raises a NodeConnectionError if the session's node disconnected. Note: This check
          will be ommited if check_connected=False.
        """
        with self._session_lock:
            if session_id in self._sessions:
                session = self._sessions[session_id]
            else:
                raise excepts.SessionError('Session not found')
            
        if not session.connected:
            raise excepts.NodeConnectionError('Node disconnected')

        return session

    def make_session(self, ip_addr):
        """Makes a new session.

        ip_addr is the IP address of the user trying to create a new session.
        Raises a excepts.SessionLimitReached if the user already has too many
        sessions.
        """
        with self._session_lock:
            session_id = None
            while session_id is None or session_id in self._sessions:
                session_id = helpers.random_str(consts.LEN_SESSION_ID)
            
            config = helpers.read_config()

            # Check how many sessions this ip_addr has running
            count = len([node_con for node_con in self._sessions.values()
                         if node_con.user_ip_addr == ip_addr])
            if count >= int(config.get('max_sessions_per_ip', 5)):
                raise excepts.SessionLimitReached()
            
            # Route the new session to the same node for this requester
            n = len(config['nodes'])
            node_address = config['nodes'][hash(ip_addr) % n]['address']
            node_con = NodeConnection(node_address, session_id, ip_addr)
            self._sessions[session_id] = node_con

        node_con.init()
        node_con.start()

        return session_id

    def end_session(self, session_id):
        """Ends a session and removes it from the master session dictionary.
        If successful, no return value.
        
        Raises a SessionError if the session was not found.
        """
        session = self._get_session(session_id, check_connected=False)
        
        with self._session_lock:
            try:
                del self._sessions[session_id]
            except KeyError:
                pass
        
        session.finish()

    def handle_command(self, session_id, command):
        """Route a command to the node.

        Raises a SessionError if session not located.
        Raises a NodeError if the node sends back an error (may be UnspecifiedNodeError,
          a sublcass, if not identified).

        If successful, returns a namedtuple with the following attributes:
          output: output of the command (str)
          image: whether or not a new image is being sent (bool)
          image_data: the image data if any (str/None)
          files_changed: whether or not any opening/closing happened
        """
        # Get the session and route the command
        session = self._get_session(session_id)
        session.send('command', {'command': command})

        # Wait for a session.command_event to be fired
        session.command_event.wait()   # can add timeout here
        with session.messages_lock:
            response = session.messages['command'].pop(0)
        session.command_event.clear()
        
        # Handle errors in the node response
        if response.get('error') == -1:
            raise excepts.UnspecifiedNodeError(response)
        elif response.get('error') is not None:
            raise excepts.NodeError(response)

        return CommandResponse(response['output'], response['image'],
                               response['image_data'], response['files_changed'],
                               response['dims_changed'], response['dims'],
                               response['ctlinfo'], response['display_expr_changed'],
                               response['display_expr'])
    

    def handle_diminfo(self, session_id):
        """Handles a request for dimension information. Returns a dictionary with
        subdictionaries 'dims' and 'ctlinfo'.

        Raises a SessionError if the session was not located.
        Raises a NoFilesOpenedError if no files are open (cannot query empty dims).
        """
        # Get the session and send out the request for diminfo
        session = self._get_session(session_id)
        session.send('diminfo', {})
                
        # Wait for a session.diminfo_event to be fired
        session.diminfo_event.wait()
        with session.messages_lock:
            response = session.messages['diminfo'].pop(0)
        session.diminfo_event.clear()

        # Send response
        dims = response['dims']
        ctlinfo = response['ctlinfo']
        if dims:
            return {'dims': dims, 'ctlinfo': ctlinfo}
        else:
            raise excepts.NoFilesOpenedError()

    def handle_nodestats(self, session_id):
        """Handles a request for node statistics. Returns a string outlining
        the statistics, to be sent to the user terminal.
        
        Raises a SessionError if the session was not located.
        """

        # Get the session and send out the request for node info
        session = self._get_session(session_id)
        session.send('nodestats', {})

        # Wait for a session.nodestats_event to be fired
        session.nodestats_event.wait()
        with session.messages_lock:
            response = session.messages['nodestats'].pop(0)
        session.nodestats_event.clear()

        return response['text']

    def handle_ping(self, session_id):
        """Handles a ping from the frontend.
        Raises a SessionError if the session was not located.
        Raises a NodeConnectionError if there was an fatal issue with such.
        """
        session = self._get_session(session_id)
        session.update_last_ping_time()        

    def handle_varinfo(self, session_id):
        """Handles a request for the node to query the file in GrADS
        and return the result.
        Raises a SessionError if the session was not located.
        """
        # Get the session and send out the request for queryfile
        session = self._get_session(session_id)
        session.send('varinfo', {})

        # Wait for a session.varinfo_event to be fired
        session.varinfo_event.wait()
        with session.messages_lock:
            response = session.messages['varinfo'].pop(0)
        session.varinfo_event.clear()

        return response['files']


class NodeConnection(threading.Thread):
    """A client connection to a node server.

    Messages in our protocol are just tuples of (msg_type, data)\n
    where data is encoded JSON, and \n is the newline.
    
    To send messages, call the send(msg_type, data) function.
    To recieve a message, wait on the corresponding message type event and
    then pop the first element of node_con.messages[msg_type]
    (while holding node_con.messages_lock). 

    An attribute is exposed called "connected" which is a boolean of
    whether the connection is reachable. It becomes True after init() 
    and becomes False only in finish() or if the Node breaks the connection
    earlier.
    """

    def __init__(self, node_address, session_id, user_ip_addr):
        self.node_address = node_address
        self.session_id = session_id
        self.user_ip_addr = user_ip_addr
        self.last_ping_time = time.time()  # we consider initial a ping

        self.messages = collections.defaultdict(list)
        self.messages_lock = threading.Lock()
        self.command_event = threading.Event()
        self.diminfo_event = threading.Event()
        self.nodestats_event = threading.Event()
        self.varinfo_event = threading.Event()

        self._socket = None
        self._end = False

        threading.Thread.__init__(self)

    def init(self, out_port=consts.NODE_LISTEN_PORT):
        logging.debug('Initializing new node connection: %s (%s)' % (self.node_address, self.session_id))
        self._socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        self._socket.connect((self.node_address, out_port))
        self._socket.send(self.session_id)
        self.connected = True

    def finish(self):
        """Cleanup method for ending the connection. This method should execute whether or
        not the node ont he other end is connected.
        """
        logging.debug('Closing node connection: %s' % self.node_address)        
        self._socket.close()
        self.connected = False
        self._end = True
        
    def run(self):
        buff = ''
        while not self._end:
            recent = self._socket.recv(1024*4)
            logging.debug('<-- RECV: %s' % repr(recent))

            # If the other node disconnects, we merely set the connected flag
            # to off and stop running.
            if not recent:
                self.connected = False
                self._end = True

            buff += recent
            
            d = buff.split('\n')
            buff = d.pop()

            for line in d:
                msg_type = line.split('::', 1)[0]
                data = json.loads(line.split('::', 1)[1])

                with self.messages_lock:
                    self.messages[msg_type].append(data)

                if msg_type == 'command':
                    self.command_event.set()
                elif msg_type == 'diminfo':
                    self.diminfo_event.set()
                elif msg_type == 'nodestats':
                    self.nodestats_event.set()
                elif msg_type == 'varinfo':
                    self.varinfo_event.set()
                else:
                    logging.error('Event not found for msg_type == %s' % repr(msg_type))
    
    def send(self, msg_type, data):
        msg = '%s::%s\n' % (msg_type, json.dumps(data))
        logging.debug('--> SEND: %s' % repr(msg)) 
        self._socket.send(msg)
    
    def update_last_ping_time(self):
        self.last_ping_time = time.time()
